전체 포스트

타입과 집합 호환성 트리

타입과 집합, 타입간의 호환성 그리고 타입 계층도를 이해해 봅시다
2/21/2023 작성

개요

안녕하세요 이정환입니다 😃

앞선 섹션에서 여러가지 타입에 대해 살펴보았습니다. 그 중에는 number나 string같은 이미 익숙한 타입 들도 있었지만 never, unknown, any, void 같은 생소한 타입들도 있었습니다. 어떤 상황에 어떻게 쓰이는 타입인지 살펴보았지만 사실 아직 조금 혼란스럽습니다. 이미 기억에서 지워졌을 수도 있습니다.

괜찮습니다. 이번 내용을 통해 타입과 집합, 그리고 여러 타입들 간의 관계에 대해 잘 알게되면 이제 더 이상 never, unknown, void, any와 같은 타입들이 혼란스럽지 않을 것 입니다. 그렇게 되면 이제 여러분은 이런 생소한 타입들과 많이 친해지게 될 것이며 적재 적소에 활용할 수 있게 됩니다.

따라서 이번에는 타입과 집합, 그리고 타입간의 관계에 대해 깊게 살펴보겠습니다.

타입은 집합이다.

여러번 이야기 했지만 아주 중요한 개념이므로 다시 반복합니다. 타입은 값의 집합입니다.

예를 들어 1, 2, 3과 같은 숫자 값의 집합은 number 타입이라고 할 수 있습니다.

위 그림에서 표현된 것 처럼 1, 2, 4, -20 등의 숫자 값들은 number 타입이라는 집합에 포함됩니다. 이렇듯 타입은 여러개의 값을 포함하는 집합입니다. 반복합니다 타입은 집합입니다.

그렇다면 number 리터럴 타입은 어떻게 생겨먹은 집합 일까요? 예를 들어 다음과 같은 number 리터럴 타입을 생각해 보겠습니다.

COPY
let a: 20 = 20;

변수 a의 타입은 20입니다. 그러므로 a에는 20이외의 다른 값을 할당할 수 없습니다. 앞서 타입은 모두 집합이라고 했습니다. 따라서 이 20이라는 타입은 다음과 같이 딱 하나의 값만 포함하는 아주 작은 집합입니다.

부모(슈퍼)타입과 자식(서브)타입

그런데 이 20이라는 값은 20 number 리터럴 타입 외에도 number 타입에도 속하는 값입니다.

사실 모든 number 리터럴 타입들은 number 타입에 속합니다. number 리터럴 타입이 포함할 수 있는 값의 종류는 숫자 값 이외에는 없기 때문입니다.

따라서 두 집합 number 리터럴 타입과 number 타입간의 관계를 표현하면 다음과 같습니다.

number 타입은 number 리터럴 타입을 포함하는 더 큰 집합입니다. 바꿔 말하면 number 리터럴 타입은 number 타입의 부분 집합입니다.

이렇듯 타입스크립트의 타입들은 모두 집합이기 때문에 서로 포함하거나 포함되는 관계를 갖습니다. 이 때 number처럼 다른 타입을 포함하는 타입을 부모 타입 또는 슈퍼 타입 이라고 합니다. 반대로 number 리터럴 처럼 다른 타입에 포함되는 타입을 자식 타입 또는 서브 타입 이라고 합니다.

부모, 자식 타입보다는 슈퍼, 서브 타입이 더 자주 사용되는 표현이므로 앞으로 우리는 부모를 슈퍼, 자식을 서브 타입이라고 표현하겠습니다.

그러므로 number는 number 리터럴의 슈퍼 타입이며. number 리터럴은 number의 서브 타입이라고 할 수 있습니다.

마찬가지로 string 타입과 string 리터럴 타입의 관계도 슈퍼-서브 타입 관계를 갖습니다. 또 boolean 타입과 boolean 리터럴 타입간의 관계도 동일합니다.

이렇듯 타입스크립트의 모든 타입들은 모두 집합이기 때문에, 다른 타입에 포함되거나 또는 다른 타입을 포함하게 됩니다. 타입스크립트에서는 이를 부모-자식 관계로 정의합니다.

타입 호환성

타입스크립트에서는 다음과 같이 어떤 변수에 선언된 타입과 동일한 타입의 값이 아니더라도 할당할 수 있는 경우가 존재합니다.

COPY
let num1: 10 = 10;
let num2: number = num1;

num1의 타입은 ‘10 number 리터럴 타입’ 입니다. 반면 num2의 타입은 number 타입입니다. 그런데 num1에 저장된 값을 num2에 저장해도 아무런 오류가 발생하지 않습니다.

num1과 num2는 다른 타입인데도 오류가 발생하지 않습니다. 둘 다 똑같은 숫자 이기 때문에 number 리터럴 타입과 number 타입 간에는 타입 검사를 아예 하지 않는 걸까요?

COPY
let num3: number = 20;
let num4: 20 = num3; // 오류 : number 타입은 20 타입에 할당 불가

그렇지는 않습니다. 반대로 number 타입의 값을 number 리터럴 타입의 변수에 할당하려고 하면 오류가 발생합니다. num3의 값이 20임에도 불구하고 오류가 발생합니다. 이상하죠

상황을 정리하면 다음과 같습니다.

  • number 타입 변수number 리터럴 타입 값 : ✅ 할당 가능
  • number 리터럴 타입 변수number 타입 값 : ❌ 타입 오류

왜 이런 상황이 발생하는 걸까요?

그 이유는 number가 number 리터럴의 슈퍼 타입이기 때문입니다. 이렇듯 타입스크립트에서는 서브 타입의 값을 슈퍼 타입의 변수에 할당할 수 있습니다. 반면 슈퍼타입의 값을 서브타입 변수에 할당하는 것은 불가능합니다.

타입 호환성이란?

타입스크립트에서는 타입 호환성이라는 개념이 존재합니다.

타입 호환성, 말은 되게 어려워 보이는데 천천히 살펴보면 그닥 어렵지 않습니다.

먼저 호환이란 무슨 뜻일까요? 호환이란 “서로 교환한다”라는 뜻 입니다. 일상 생활에서도 자주 쓰이는 단어 인데요 예를 들어 A라는 기계의 부품을 B 라는 기계에도 끼워서 쓸 수 있으면 우리는 이를 흔히 ‘호환된다’ 라고 표현합니다.

반대로 아이폰 충전기로 아이폰은 충전 할 수 있지만 삼성 핸드폰은 충전할 수 없습니다. 이렇게 바꿔 쓸 수 없는 상황을 호환되지 않는다 라고 합니다. 호환은 이렇게 “서로 교환해서 사용해도 무리가 없는 또는 이상이 없는” 정도의 뜻으로 이해할 수 있습니다.

타입 호환은 타입을 바꿔도 괜찮은 경우를 의미합니다. 타입스크립트에서는 서브타입의 값을 슈퍼타입의 변수에 할당하는 것은 호환됩니다. 그러나 반대로 슈퍼타입의 값을 서브타입에 할당하는 것은 불가능합니다. 앞에서 살펴본 예제를 다시 보겠습니다.

COPY
let num1: 10 = 10;
let num2: number = num1;

let num3: number = 20;
let num4: 20 = num3; // 오류 : number 타입은 20 타입에 할당 불가

서브타입인 number 리터럴 타입의 값 10을 슈퍼타입인 number 타입의 변수 num2에 할당하는 것은 되었습니다. 이렇듯 타입스크립트에서는 서브타입의 값을 슈퍼타입에 할당하는 것은 가능합니다.

반대로 슈퍼타입인 number 타입의 값을 서브타입인 number 리터럴 타입의 변수에 할당하려고 하면 오류가 발생합니다. 이렇듯 슈퍼타입의 값을 서브 타입에 할당하는 것은 불가능합니다.

업캐스트와 다운캐스트

왜 서브타입의 값은 슈퍼타입에 할당할 수 있는 걸까요?

그 이유는 간단합니다. 서브타입은 슈퍼타입의 부분집합이기 때문입니다.

서브타입의 값을 슈퍼타입의 변수에 할당한다는 것은 서브타입 값을 슈퍼타입 값으로 생각하겠다는 것 과 의미가 동일합니다. 위 예제에 빗대어 보면 number 리터럴 타입 값 10을 number 타입으로 생각하겠다고 이해할 수 있습니다. 이것은 전혀 문제가 되지 않습니다. 10은 애초에 number 타입에도 속하는 값 이기 때문입니다.

결론적으로 서브타입의 값은 슈퍼타입에 할당 가능합니다. 이것을 업캐스트라고 부릅니다.

반대로 슈퍼타입 값을 서브타입의 변수에 할당하는 건 불가능합니다.

큰 집합을 더 작은 집합으로 간주하는 것은 불가능하기 때문입니다. 이것은 문제가 됩니다. 예를 들어 number 타입 값 99를 1 number 리터럴 타입의 값으로 간주 하겠다고 하는 것과 동일합니다. 이것은 불가능합니다 1 number 리터럴 타입에는 99라는 값이 존재하지 않기 때문입니다.

이것은 다운캐스트라고 합니다. 타입스크립트에서 업캐스트는 모두 가능하며 다운 캐스트는 기본적으로 불가능합니다. 그러나 몇몇 예외 경우에는 가능한데 나중에 자세히 다룹니다.

정리하자면 다음과 같습니다.

  • 서브타입의 값을 슈퍼타입에 할당 : 업캐스트 (가능 ✅)
  • 슈퍼타입의 값을 서브타입에 할당 : 다운캐스트 (대부분 불가능 ❌)

이렇게 타입스크립트의 타입 호환성과 함께 타입간의 호환(업캐스트, 다운캐스트)이 어떻게 이루어지는지 살펴 보았습니다.

다음에는 모든 타입의 부모-자식 관계를 나타내는 타입 계층도를 살펴보며 void, any, unknown, never 타입에 대해 이해해 보겠습니다.

타입 계층도

아래는 타입스크립트가 제공하는 모든 타입간의 관계를 표현한 타입 계층도입니다.

앞서 우리는 모든 타입은 집합이며 슈퍼-서브 타입 관계를 갖는다는 사실을 알았습니다. 또 서브타입은 슈퍼타입으로 언제나 업캐스트 할 수 있다는 사실도 함께 살펴봤습니다.

따라서 이 타입 계층도의 맨 밑바닥에 있는 never 타입의 경우 모든 타입으로 업캐스트 할 수 있고, 맨 위에 있는 unknown의 경우 어떤 타입의 변수에도 할당할 수 없다는 것 또한 알 수 있습니다.

이번에는 이 타입 계층도를 통해 우리가 헷갈렸던 never, void, unknown, any와 같은 타입들에 대해 확실하게 알아보겠습니다.

unknown 타입 (전체 집합)

unknown 타입은 타입 계층도의 최 상단에 위치합니다.

따라서 unknown 타입 변수에는 모든 타입의 값을 할당할 수 있습니다. 바꿔 말하면 모든 타입은 unknown 타입으로 업 캐스트 할 수 있습니다.

COPY
let a: unknown = 1; // number -> unknown
let b: unknown = "hello"; // string -> unknown
let c: unknown = true; // boolean -> unknown
let d: unknown = null; // null -> unknown
let e: unknown = undefined; // undefined -> unknown
let f: unknown = []; // Array -> unknown
let g: unknown = {}; // Object -> unknown
let h: unknown = () => {}; // Function -> unknown

unknown 타입이 타입 계층도에서 가장 위에 위치한다는 뜻은 unknown 타입은 모든 타입의 슈퍼타입이라는 뜻 입니다. 그러므로 모든 타입은 unknown 타입의 부분집합입니다.

결국 unknown 타입은 모든 타입을 부분집합으로 갖는 타입스크립트 전체 집합 입니다.

앞서 다운캐스트는 예외적인 경우가 아니면 허용되지 않는다고 배웠습니다. 따라서 unknown 타입의 값은 any를 제외한 어떤 타입의 변수에도 할당할 수 없습니다.

COPY
let unknownValue: unknown;

let a: number = unknownValue;
// 오류 : unknown 타입은 number 타입에 할당할 수 없습니다.

예외적으로 any 타입의 변수 에는 unknown 타입의 값을 할당할 수 있습니다. 즉 다운캐스트가 가능합니다.

COPY
let unknownValue: unknown;

let a: any = unknownValue;

이것이 허용되는 이유는 any는 일종의 ‘치트키’ 타입이기 때문입니다.

any 타입 (치트키)

any 타입은 사실상 타입 계층도를 완전히 무시합니다. any는 일종의 치트키같은 타입입니다.

any는 뭐든지 예외입니다. 모든 타입의 슈퍼타입이 될 수도 있고 모든 타입의 서브 타입이 될 수도 있습니다.

COPY
let anyValue: any;

let num: number = anyValue; // any -> number (다운 캐스트)
let str: string = anyValue; // any -> string (다운 캐스트)
let bool: boolean = anyValue; // any -> boolean (다운 캐스트)

anyValue = num; // number -> any (업 캐스트)
anyValue = str; // string -> any (업 캐스트)
anyValue = bool; // boolean -> any (업 캐스트)

각각 number, string, boolean 타입을 갖는 변수 num, str, bool에 any 타입의 값을 할당합니다. 이는 any 타입이 각각 number, string, boolean 타입으로 다운 캐스트 된다고 이해할 수 있습니다.

또 any 타입 변수 anyValue에 num, str, bool 변수에 담긴 값을 할당합니다. 이는 number, string, boolean 타입이 모두 any 타입으로 업 캐스트 되는 것으로 이해할 수 있습니다.

이렇듯 any 타입은 모든 타입으로 다운캐스트 할 수 있으며 또 모든 타입은 any 타입으로 업 캐스트 할 수 있습니다. any 타입은 심하게 말하면 타입 시스템을 무너뜨리고 있으며 치트키 타입입니다.

never 타입 (공집합 타입)

never 타입은 타입 계층도에서 가장 아래에 위치합니다.

앞서 never 타입은 불가능, 모순을 의미하는 타입이라고 설명한 적이 있습니다. 타입이 집합임을 이해한 지금 never 타입을 다시 표현하자면 never는 공집합을 뜻하는 타입입니다. 수학에서의 공집합은 아무것도 포함하지 않는 집합이라는 뜻 입니다.

따라서 never 타입에 해당하는 값은 말 그대로 아무것도 없습니다. 따라서 다음과 같은 상황에 never 타입이 주로 사용됩니다.

COPY
function errorFunc(): never {
  throw new Error();
}

errorFunc 함수는 에러를 발생시킵니다. 따라서 이 함수는 정상적으로 종료되지 않습니다. 그러므로 어떤 값도 반환할 수 없습니다. 만약 이 함수가 어떤 값을 반환한다면 그것은 불가능하며 모순입니다.

또는 다음과 같이 인터섹션 타입으로 만든 number와 string 타입의 교집합 타입 또한 never 타입을 갖습니다.

COPY
type thisIsNever = number & string;
// never 타입으로 추론된다.

number와 string은 타입은 서로 공유하는 값이 하나도 없습니다. 그 어떤 값도 문자열이면서 숫자일 수는 없습니다. 따라서 이 둘의 교집합은 공집합이 되므로 never 타입을 갖게 됩니다.

또 공집합은 모든 집합의 부분 집합입니다. 그러므로 never 타입은 모든 타입의 서브 타입입니다. 따라서 never 타입은 모든 타입으로 업캐스팅 할 수 있습니다.

COPY
let neverVar: never;

let a: number = neverVar; // never -> number
let b: string = neverVar; // never -> string
let c: boolean = neverVar; // never -> boolean
let d: null = neverVar; // never -> null
let e: undefined = neverVar; // never -> undefined
let f: [] = neverVar; // never -> Array
let g: {} = neverVar; // never -> Object

반면 그 어떤 타입도 never 타입으로 다운 캐스팅 할 수 없습니다.

COPY
let a: never = 1; // number -> never ❌
let b: never = "hello"; // string -> never ❌
let c: never = true; // boolean -> never ❌
let d: never = null; // null -> never ❌
let e: never = undefined; // undefined -> never ❌
let f: never = []; // Array -> never ❌
let g: never = {}; // Object -> never ❌

한 가지 놀라운 점은 타입스크립트의 치트키 타입인 any 타입 또한 never 타입으로 다운 캐스팅 할 수 없습니다. never는 유일하게 any가 다운캐스팅 할 수 없는 타입입니다.

COPY
let anyVar: any;
let neverVar: never = anyVar;
// 오류 : any 타입은 never 타입에 할당할 수 없습니다.

void 타입

다음으로 살펴볼 타입은 void 타입입니다.

void 타입은 앞서 다음과 같이 아무것도 반환하지 않는 함수의 반환값 타입으로 주로 사용된다고 살펴본 적 있습니다.

COPY
function noReturnFunc(): void {
  console.log("hi");
}

타입 계층도에서 void 타입을 찾아보면 void 타입은 undefined 타입의 슈퍼타입임을 알 수 있습니다.

따라서 반환값을 void로 선언한 함수에서 undefined을 반환 해도 오류가 발생하지 않습니다. undefined 타입은 void 타입의 서브 타입이므로 업캐스팅이 가능하기 때문입니다.

COPY
function noReturnFuncA(): void {
  return undefined;
}

function noReturnFuncB(): void {
  return;
}

function noReturnFuncC(): void {}

void 타입의 서브타입은 undefined 타입과 never 타입 밖에 없습니다. 따라서 void 타입에는 undefined, never 이외에 다른 타입의 값을 할당할 수 없습니다.

COPY
let voidVar: void;

voidVar = undefined; // undefined -> void (ok)

let neverVar: never;
voidVar = neverVar; // never -> void (ok)

let anyVar: any;
voidVar = anyVar; // any -> void (ok)

추가로 치트키 타입 any 타입 또한 never를 제외한 모든 타입으로 다운캐스팅 할 수 있다고 했으므로 void 타입 변수에 any 타입 값을 할당할 수 있습니다.

null 타입

타입 계층도를 살펴보면 null 타입은 never 타입 이외에는 어떠한 타입의 슈퍼타입도 아닙니다.

그런데 tsconfig.json의 strictNullCheck(엄격한 널 검사) 옵션을 끄면(false로 설정) 모든 타입에 null을 할당하는게 가능합니다. 이는 앞서 null에 대해 살펴볼때 다룬 적 있습니다.

다음과 같이 tsconfig.json의 strictNullCheck 옵션을 false로 설정합니다.

COPY
{
  "compilerOptions": {
    ...
    "strictNullChecks": false,
    ...
  },
  ...
}

이제 타입 계층도를 무시하고 null을 모든 타입 변수에 할당하는 것이 가능합니다.

COPY
let num: number = null;
let str: string = null;
let bool: boolean = null;
let object: { name: string } = null;
let arr: string[] = null;

let voidVar: void = null;
let undefinedVar: undefined = null;

그러나 any와 마찬가지로 never 타입의 변수에는 할당할 수 없습니다.

COPY
let neverVar: never = null;